iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

Day 26 完成了 Docker Compose 部署,系統可以一行指令啟動。但 Day 24 留下了一個沒解決的警訊:在一份 58 條需求的規格書 srs_medium.md 上,LLM 補充層回報了 17 個衝突,全部是誤報;規格書自己列出的 3 個已知矛盾,則一個都沒找到。當時的補充 prompt 已經用掉 2,623 個 token,接近 Mistral 7B 的 4096 上限。

第 3 週的所有結論(F1 0.897)都來自 Day 7-10 的資料集:每份只有 8-11 條需求,而且刻意放滿衝突。真實的規格書通常更長、衝突更少。今天要用數據回答:這套系統能處理多大的規格書?規模變大時,是哪一層先出問題?

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 24 → 命令列工具(發現補充層在大文件上的問題)
  • Day 25-26 → Docker 與 Docker Compose
  • Day 27 → 大規模性能基準 ← 今天
  • Day 28 → 使用文檔與同步檢查

今日目標

讀完這篇後,你會完成:

  1. 合成規格書產生器 src/benchmark.py:任意大小、衝突已知、可重現
  2. 規則層擴展性:從 10 條到 1000 條需求,量測候選數與耗時(不需要 LLM)
  3. 完整檢測的規模測試:10 到 150 條需求,用 Mistral 7B 量測 F1、各層的錯誤與呼叫次數
  4. 找出並處理 prompt 截斷:OLLAMA_NUM_CTX、OLLAMA_TIMEOUT、截斷警告
  5. 測試 tests/test_day27_benchmark.py:6 個離線測試

問題背景:為什麼要合成規格書

要量測「規模」,需要一系列大小不同、但其他條件相同的規格書,而且每一份的正確答案都要已知。手工標註上百條需求的規格書並不實際,所以用程式產生:

設計 理由
大部分是「一般需求」(模組 × 功能,例如「會員中心支持深色模式」) 貼近真實文件:大多數需求彼此無關
每 10 條需求放 1 組已知衝突 衝突是少數,不是 Day 7 資料集那樣放滿
三種衝突輪流:規則型、數值型、語意型 分別對應規則層、補充層 H2、補充層的語意理解
打亂順序,衝突的兩條不相鄰 Day 24 看到補充層會配對「相鄰」的需求,不能讓它碰巧答對
不使用 v2 prompt 例子的主題 Day 19 的原則:避免模型背答案
固定隨機種子 同樣的參數永遠產生同一份規格書

實現方法

generate_srs(n, conflict_every=10, seed=27)
  → SyntheticSRS(constraints, expected_conflicts, conflict_kinds)

python3 -m src.benchmark rules --sizes 10 50 100 200 500 1000
  → 規則層:需求對數、候選數、規則型衝突召回、耗時、預估 LLM 呼叫

python3 -m src.benchmark llm --sizes 10 30 60 100 150
  → Day 20 設定(驗證 v1 + 補充 v2 + guardrail)完整檢測
  → 透過 Day 18 的 analyze() 計分與歸類
  → 另外請 Ollama 回報補充 prompt 實際讀到的 token 數
  • 輸入:需求數、衝突比例、隨機種子
  • 輸出:reports/day27_benchmark.md、reports/day27_benchmark_ctx8192.md
  • 檔案:src/benchmark.py(新增);src/llm_verifier.py(修改:OLLAMA_NUM_CTX、OLLAMA_TIMEOUT、截斷警告)
  • 下游:Day 28 的使用文件把今天的發現寫進疑難排解;Day 30 的 README 據此說明限制

專案結構變化:

srs-review-agent/
├── src/
│   ├── benchmark.py           ← 【新增】合成規格書、規則層擴展性、LLM 基準
│   └── llm_verifier.py        ← 修改:num_ctx、timeout、PromptTruncatedWarning
├── tests/
│   └── test_day27_benchmark.py ← 【新增】6 個測試
└── reports/
    ├── day27_benchmark.md      ← 【新增】預設上下文長度
    └── day27_benchmark_ctx8192.md ← 【新增】num_ctx=8192

代碼示例

1. 合成規格書

建立 src/benchmark.py:

# 一般需求:模組 × 功能。刻意避開規則關鍵字(多用戶、單用戶、加密、明文、刪除、保存)
# 與 Day 19 v2 prompt 例子中的主題(日誌、訂單、管理後台、上傳、檔案)
MODULES = ["會員中心", "商品目錄", "搜尋服務", "通知中心", "報表模組", "客服系統",
           "行事曆", "知識庫", "排程服務", "儀表板", "推薦引擎", "評論系統"]
FEATURES = ["支持依名稱排序", "提供分頁瀏覽", "支持關鍵字篩選", "提供繁體中文介面", ...]

# 已知衝突的三種型態({m} 代入模組名稱)
CONFLICT_TEMPLATES = {
    "rule": ("{m}支持多用戶同時操作", "{m}採用單用戶模式,同一時間只允許一人使用"),
    "numeric": ("{m}每頁最多顯示 50 筆資料", "{m}每頁最多顯示 10 筆資料"),
    "semantic": ("{m}的資料即時同步到伺服器", "{m}僅支援離線模式,完全不連線伺服器"),
}

12 個模組 × 12 種功能,共 144 句一般需求;超過時加上「(v2)」之類的版本號,確保每一條原文都不重複。generate_srs() 依序放入衝突、補上一般需求、打亂,最後編號為 REQ-1 到 REQ-n,同時記下哪兩條是一組衝突、屬於哪一型。完整代碼見 src/benchmark.py。

產生出來的 30 條需求片段:

REQ-1   報表模組支持深色模式
REQ-2   客服系統採用單用戶模式,同一時間只允許一人使用
REQ-5   評論系統的資料即時同步到伺服器
REQ-8   推薦引擎每頁最多顯示 10 筆資料
REQ-12  推薦引擎每頁最多顯示 50 筆資料
...
已知衝突:(REQ-2, REQ-26) 規則型、(REQ-5, REQ-23) 語意型、(REQ-8, REQ-12) 數值型

2. 規則層擴展性

規則層不需要 LLM,可以量到上千條:

def measure_rules(n: int, batch_size: int = 10, seed: int = 27) -> RuleScaling:
    srs = generate_srs(n, seed=seed)
    detector = OptimizedDetector()
    start = time.perf_counter()
    candidates = detector._detect_simple_conflicts(srs.constraints)
    seconds = time.perf_counter() - start

    found = {frozenset((c.req_id_1, c.req_id_2)) for c in candidates}
    rule_pairs = [p for p, kind in srs.conflict_kinds.items() if kind == "rule"]
    recall = sum(1 for p in rule_pairs if frozenset(p) in found) / len(rule_pairs) if rule_pairs else 1.0
    verify_calls = math.ceil(len(candidates) / batch_size)
    ...

除了候選數與耗時,也依 Day 12 的批量大小(每批 10 個)推算 LLM 呼叫次數:驗證 ⌈候選數 / 10⌉ 次、補充 1 次,開啟 Day 21 的第 4 層時再加上需求條數。

3. 量測補充 prompt 實際被讀了多少

def supplement_prompt_tokens(constraints, version="v2", ...) -> Dict:
    """請 Ollama 只讀 prompt、不生成(num_predict=1),回報實際的 prompt token 數"""
    prompt = build_supplement_prompt(constraints, version)
    options = {"temperature": 0, "num_predict": 1}
    if os.getenv("OLLAMA_NUM_CTX"):   # 與 OllamaLLM 使用相同的上下文長度
        options["num_ctx"] = int(os.getenv("OLLAMA_NUM_CTX"))
    ...
    return {"chars": len(prompt), "prompt_tokens": data.get("prompt_eval_count")}

num_predict=1 讓模型只讀 prompt、幾乎不生成,幾秒就能得到 prompt_eval_count:Ollama 實際送進模型的 token 數。這個數字後來揭露了今天最重要的發現。


驗證結果

測試(離線)

cd srs-review-agent
python3 -m pytest tests/test_day27_benchmark.py -v
tests/test_day27_benchmark.py::test_generator_is_deterministic PASSED    [ 16%]
tests/test_day27_benchmark.py::test_generator_shape PASSED               [ 33%]
tests/test_day27_benchmark.py::test_generator_large_and_invalid PASSED   [ 50%]
tests/test_day27_benchmark.py::test_no_leak_from_prompt_examples PASSED  [ 66%]
tests/test_day27_benchmark.py::test_rule_scaling PASSED                  [ 83%]
tests/test_day27_benchmark.py::test_truncation_warning PASSED            [100%]

============================== 6 passed in 0.08s ===============================

規則層擴展性

python3 -m src.benchmark rules
需求數 需求對 規則候選 規則型衝突召回 耗時(毫秒) 預估 LLM 呼叫(不含 / 含自我矛盾)
10 45 1 100% 0.0 2 / 12
50 1,225 4 100% 0.3 2 / 52
100 4,950 16 100% 1.0 3 / 103
200 19,900 49 100% 4.1 6 / 206
500 124,750 289 100% 28.1 30 / 530
1000 499,500 1156 100% 111.3 117 / 1117

兩個觀察:

  • 速度不是問題:1000 條需求、近 50 萬個需求對,規則層 111 毫秒就完成
  • 候選數是問題:1000 條需求裡只有 33 組規則型衝突,候選卻有 1156 個。原因是關鍵字規則不看「對象」:「會員中心支持多用戶同時操作」會和每一個其他模組的「採用單用戶模式」配成一對。有 k 組規則型衝突時,候選數大約是 k²,全部都要送去 LLM 驗證

完整檢測(Mistral 7B,預設上下文長度)

python3 -m src.benchmark llm --sizes 10 30 60 100 150 --out reports/day27_benchmark.md
需求數 已知衝突 補充 prompt token Precision Recall F1 FP(規則 / 補充) FN LLM 呼叫 秒
10 1 663 0.333 1.000 0.500 0 / 2 0 2 13.8
30 3 1461 0.500 0.667 0.571 0 / 2 1 2 18.8
60 6 2654 0.333 0.167 0.222 2 / 0 5 4 172.4
100 10 2051 0.200 0.300 0.240 12 / 0 7 8 681.0
150 15 2051 0.150 0.200 0.171 17 / 0 12 6 245.0

和 Day 20 在 Day 7 資料集上的 0.897 相比,落差非常大。把 60 條需求那一份用 Day 18 的分類拆開看:

結果 / 類別 數量
TP / 規則層 1
FP / 規則誤報、驗證放行 2
FN / 規則命中、驗證誤刪 1
FN / 兩層都沒找到 4
  • 補充層什麼都沒找到:既沒有正確結果,也沒有誤報。在 10、30 條時它還會回報(各 2 個誤報),到了 60 條就沉默了
  • 驗證層第一次出現「誤刪」:一組真正的「多用戶 vs 單用戶」被驗證層丟掉了。Day 18 時驗證層從沒誤刪過
  • 規則誤報被放行:跨模組的「多用戶 vs 單用戶」被驗證層判為衝突。這和 Day 18 的「描述對象不同」是同一類錯誤,只是數量隨規模變多:100 條時 12 個、150 條時 17 個

最重要的發現:prompt 被默默截斷

表格中最奇怪的是「補充 prompt token」這一欄:60 條需求 2654 個,100 條和 150 條卻都是 2051,比 60 條還少,而且兩者一模一樣。

prompt 明明變長了,模型讀到的卻變少了,代表 prompt 被截斷了。驗證方法是把上下文長度加大到 8192 再讀一次:

需求數 prompt 字元 預設上下文長度 num_ctx=8192
60 3,731 2,654 token 2,654 token
100 5,992 2,051 token 4,221 token
150 8,900 2,051 token 6,303 token

100 條需求的補充 prompt 實際有 4,221 個 token,超過了 Mistral 的 4096 上限,Ollama 就把它截到 2,051 個 token,沒有回報任何錯誤。模型只看到大約一半的需求,連 prompt 開頭的指示都可能被截掉。

Day 24 的 58 條規格書(2,623 token)還在上限之內,所以當時的問題是誤報,不是截斷。

處理截斷

修改 src/llm_verifier.py 的 OllamaLLM,做三件事:

# 實測(Mistral 7B,中文需求):每個字元約 0.70-0.71 個 token;低於 0.5 視為被截斷
MIN_TOKENS_PER_CHAR = 0.5

def __init__(self, model_name=None, base_url=None, timeout=None, num_ctx=None):
    ...
    # 逾時(秒):Ollama 不串流時要等整段生成完才回應;加大 num_ctx 後一次可能超過 2 分鐘(Day 27 實測)
    self.timeout = timeout or int(os.getenv("OLLAMA_TIMEOUT", "120"))
    # 上下文長度;未設定時使用 Ollama / 模型的預設值
    env_ctx = os.getenv("OLLAMA_NUM_CTX")
    self.num_ctx = num_ctx or (int(env_ctx) if env_ctx else None)
self.last_prompt_tokens = data.get("prompt_eval_count")
if self.last_prompt_tokens and self.last_prompt_tokens < len(prompt) * self.MIN_TOKENS_PER_CHAR:
    self.truncated_calls += 1
    warnings.warn(
        f"prompt 可能被截斷:{len(prompt)} 字元只讀了 {self.last_prompt_tokens} token。"
        f"請設定 OLLAMA_NUM_CTX(例如 8192)或減少一次送出的需求數。",
        PromptTruncatedWarning,
        stacklevel=2,
    )
  1. 偵測:上面三組數據中,字元與 token 的比例很穩定(3,731 → 2,654、5,992 → 4,221、8,900 → 6,303,都約 0.70)。Ollama 回報的 token 數低於字元數的一半,就幾乎可以確定被截斷了
  2. 可設定的上下文長度 OLLAMA_NUM_CTX:傳給 Ollama 的 options.num_ctx
  3. 可設定的逾時 OLLAMA_TIMEOUT:這是第一次用 num_ctx=8192 重跑時撞到的。60 條需求的補充層生成超過了原本寫死的 120 秒,guardrail 的連線重試 2 次之後放棄,整個基準測試中斷。上下文變大,生成也跟著變慢

用 100 條需求實際呼叫一次:

預設上下文長度:tokens 2051,發出警告「prompt 可能被截斷:5992 字元只讀了 2051 token。請設定 OLLAMA_NUM_CTX(例如 8192)…」
num_ctx=8192:tokens 4221,沒有警告

tests/test_day27_benchmark.py 的 test_truncation_warning 用假的 urlopen 重現這兩種情況,並確認 num_ctx 確實傳給了 Ollama。

上下文長度夠了,品質會變好嗎

OLLAMA_NUM_CTX=8192 OLLAMA_TIMEOUT=900 python3 -m src.benchmark llm --sizes 60 100 \
  --out reports/day27_benchmark_ctx8192.md
需求數 補充 prompt token 結果
60 2,654 F1 0.222,TP 1 / FP 2 / FN 5,與預設上下文長度完全相同
100 4,221(完整讀入) 逾時:補充層單次呼叫超過 900 秒,重試 2 次後放棄;整個實驗約 63 分鐘後中斷

60 條需求本來就沒有被截斷(2,654 < 4,096),加大上下文當然不會改變結果。真正的問題在 100 條:prompt 終於被完整讀入了,但 Mistral 7B 在這台機器上一次生成就超過 15 分鐘。可能的原因是 4,221 個 token 的輸入本身就要較長的處理時間,而補充層又要為 100 條需求輸出一份 JSON;本文沒有進一步拆解每一段的耗時。

所以「加大上下文」在技術上解決了截斷,在實務上卻不可行。這一輪實驗也讓 src/benchmark.py 多了一段處理:某個規模逾時時,記錄一列「逾時」並繼續下一個規模,而不是讓整個基準中斷:

try:
    report = analyze([srs], llm=OllamaLLM(), **CONFIGS["improved"])[0]
except ConnectionError:
    # 加大 num_ctx 後,一次生成可能超過 OLLAMA_TIMEOUT(Day 27 實測);記錄後繼續下一個規模
    ...
    continue

reports/day27_benchmark_ctx8192.md 依這次的執行紀錄整理;沒有記錄到的欄位(60 條的呼叫次數與秒數)標示為「未記錄」,不另外估算。

結論:這套系統能處理多大的規格書

規模 狀況 建議
約 30 條以內,且衝突不少 接近 Day 7 資料集的條件,F1 0.5-0.9 完整設定(驗證 + 補充)
約 60 條 補充層沉默、跨模組的規則誤報開始出現 --no-supplement,結果全部人工複核
100 條以上 補充 prompt 超過 4096 token 被截斷;加大上下文則太慢 --no-supplement;或把規格書依章節拆開分析

規模變大時,最先出問題的是補充層(截斷、沉默、誤報),其次是規則層的跨模組配對(候選數約為 k²)。驗證層本身在 60 條時也出現了第一次誤刪。這三個問題都不是調 prompt 能解決的,需要改變架構:補充層依章節分批送出、規則層加上「描述對象」的比對。


權衡與限制

  • 合成規格書不是真實規格書:句型單調、衝突的寫法固定。它擅長回答「規模變大時會怎樣」,不擅長回答「在你的規格書上準不準」
  • 每個規模只跑 1 次:完整檢測在 100 條需求時要 10 分鐘以上,沒有重複執行。Day 18-21 的經驗是 Mistral 在固定 prompt 下結果穩定,但耗時差異很大
  • 截斷偵測是估算:每字元 0.5 token 的門檻是從中文需求量到的。英文或程式碼比例不同,可能誤報或漏報
  • 加大上下文不是免費的:需要更多記憶體,生成也更慢。真正的解法是補充層不要一次送出整份需求,而是依章節分批,這留給之後的版本

提交變更

git add src/benchmark.py src/llm_verifier.py tests/test_day27_benchmark.py \
        reports/day27_benchmark.md reports/day27_benchmark_ctx8192.md
git commit -m "Day 27: 大規模基準、prompt 截斷偵測、OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT"

明天預告

這兩天累積了不少使用者必須知道的事:補充層在大型規格書上的誤報、--no-supplement、OLLAMA_NUM_CTX、OLLAMA_TIMEOUT,還有 Day 13-26 散落在各篇文章的環境變數與參數。明天 Day 28 我們把它們整理成一份完整的使用指南,並用測試確保文件與程式不會再各說各話。


上一篇
Day 26:Docker Compose 部署
下一篇
Day 28:使用文檔與同步檢查
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言